先複習一下上一個章節裡我們做了什麼,首先是將 Content
的按鈕行為拆成四個步驟:
而我們對 Content
驗證了「按下按鈕後 dispatch
是否會傳入正確的 Action 執行。」和「Store 內如果有資料那 Content
會不會正常顯示」這兩件事情,也就是上方的第一和四點,也許大家覺得很疑惑,為什麼不對 Fetch
做 Mock 然後直接對最後的 Content
有沒有 Render 新資料做驗證?而是要拆成四個部分,分別做測試。
這就回到了所謂的「單一行為」,如果我們將以上四點都放在一個測試案例中,也就是直接撰寫「按下了按紐,然後確認畫面是不是有 Render 新資料」,那問題來了,當這個測試出錯的時候,可以馬上發現錯誤是哪裡造成的嗎?
沒辦法對吧?雖然這麼說,但也是有所有單元測試都通過,最後卻無法執行的狀況,例如:
(圖片來源:http://blog.sina.com.cn/s/blog_6617b7740100pihp.html)
門是門、陽台是陽台、鐵軌是鐵軌,各個功能都正常,組合起來卻悲劇到不行,因此單元測試只是對程式碼的第一道防線,除此之外還需要其他測試整合,例如端對端測試(所謂的人工測試),端對端測試是以使用者的角度去做測試,測試出來的結果也比較接近使用者的需求,但缺點就是測試耗費的成本較高,不像單元測試可以在幾秒間就得到結果,但個單元組合起來也可能造成專案運行失敗。
本篇會針對 Redux-Saga 與 React-Redux 的 Reducer 行為進行單元測試。
因為當時在學習 Redux-Saga 的時候,把 Saga 的 function 都放在 Action 中,而且負責獲取資料的 fetchData
也沒有抽出來,再進行測試前先來整理一下這些程式碼,首先打開 src/action/todolist.js,看到這個 fetchData
,現在它是這個樣子:
第一步先將 call
內的 fetch
拆成一個 function:
接著在 src 下建立一個目錄 api,我們把負責做 api 請求的 function 放在那裡,例如 getContent
:
|-src
|-api
|-content.js
除此之外,我也要把 Action 裡像 fetchData
與 mySaga
關於 Saga 的部分移出去,存放位置會放在 src/sagas 下,我會在那裡建立一個 content.js 管理屬於 content
的非同步 Flow。
src/sagas/content.js 的內容如下,因應待會要做測試,所以我把會執行的 fetchData
做 export
:
這裡要注意別忘了修改 src/sagas/index.js,因為 mySaga
的位置改變了,然後我也順勢重新命名 Saga 的名稱:
這麼一來 src/action/todolist.js 裡就真的只剩 Action 而已:
最後我們做了那麼多改動,如果是使用端對端測試,可能就要把專案運行起來,然後將所有相關功能都測過一遍,但是現在我們有了第一道防護網「單元測試」,試著執行 npm run test
吧!
確認所有的測試案例都通過後,甚至連專案都不需運行就知道沒問題了!
整理好後,並確認原有功能都沒受到影響後,就能繼續寫下新的測試案例了,首先在__tests__ 底下建立 sagas 目錄,然後創建 content.test.js
,接下來會在這裡寫下關於 src/sagas/content.js 的測試。
對 Saga 的單元測試很有趣,因為 Saga 內都是用 Generator Function,所以它每執行一次都會停在 yield
(如果忘記了可以到這裡複習),以 fetchData
為例,要驗證的是它是否會按照我們理想中的順序,以:
call
呼叫 getContent
獲取資料put
觸發 Reducer三個部分執行,只要都正確就沒問題了!
第一步先把與 fetchData
內有執行到的 function 都 import
進來,並把 fetchData
的執行交給變數 generator
:
然後用 next()
一步一步看它每次停在 yield
的部分是否如我們所想,第一步就是驗證是否是使用 call
呼叫 getContent
:
// 取出第一次執行到 yield 的部分
const callGetDataApi = generator.next().value;
// 驗證是不是用 call 呼叫 getContent
expect(callGetDataApi).toEqual(call(getContent));
第二步是確認有沒有將 getContent
回傳的資料用 put
觸發 Reducer,這時候我們還沒有用 Mock 隔離 fetch
,以後也不需要,因為當我們再一次對 generator
執行 next()
時,帶入 next
的參數就會放進上一次執行的結果,例如我這麼執行:
// 執行 next 時帶入 123
const successGetData = generator.next('123').value;
// ==== 分隔線 ====
// src/sagas/content.js
export function* fetchData() {
// 123 會被丟回上一次執行的 data 中
const data = yield call(getContent);
yield put(fetchDataSuccess(data));
}
也就是說我可以利用把參數傳入 next
來偽造 yield call(getContent)
回傳的值,因此第二次執行可以這麼驗證是否有使用 put
把獲取到的資料丟給 Reducer:
const successGetData = generator.next('mockResponse').value;
expect(successGetData).toEqual(put(fetchDataSuccess('mockResponse')));
最後要驗證的是,該次的 Generator Function 結束了沒,這裡使用 .next().done
判斷 true
或 false
,最後 FetchData_Execute_ApiFlow 的測試案例會長這樣子:
完成後輸入 npm run test
,執行測試,確認是否有問題:
記得我在昨天的時候說過 Redcuer 其實不難對吧!因為它就只是個純函數,待會會來驗證 src/reducer/todolist.js 的 FETCH_DATA_SUCCESS
有沒有問題,是否能將拿到的資料寫入 State 的正確欄位。
第一步仍然是在__tests__ 中新增 reducer 目錄,然後建立測試檔案:
|-__tests__
|-reducer
|-todolist.test.js
並將要測試的 Reducer 和 Action import
:
這裡複習一下 Reducer,它是個函數,在執行時會傳入兩個參數,第一個是 State,第二個是 Action 物件,Reducer 會依照 Action 的 Type 選擇對 State 做什麼事情,並在完成後將 State 回傳。
以 todolistReducer
為例,不論初始的 State 是什麼,我總是期望它接收到 fetchDataSuccess
產生的 Action 時,能夠將新值寫到 State 中的 data
內,因此就能這麼做斷言:
上方給 todolistReducer
第一個參數的初始 State 是 {}
空物件,第二個參數是 fetchDataSuccess
產生的 Action,得到的結果會希望原本的 {}
空物件多了 data
屬性,其值為送入 fetchDataSuccess
的參數。
完成後便可執行測試,可以看到又多一個 PASS 紀錄了:
至於 src/action/todolist.js 內的 fetchDataSuccess
是否需要被驗證就看個人了,因為在 Todolist Reducer 的測試案例中,也沒有替 fetchDataSuccess
製作 Mock,如果 fetchDataSuccess
產生的 Action 發生錯誤時,那該測試案例也不會成功。
而沒有替 fetchDataSuccess
使用 Spy 的原因是因為我可以從「結果」,也就是 Reducer 回傳的值就能知道它到底有沒有執行,而不是再對 Spy 做 toHaveBeenCalled
的斷言,如果在測試案例裡同時驗證「結果」與「執行過程」,就等於在一個測試案例裡測了兩種相同東西,因為為了要產生結果「執行過程」就是必要的,如果沒有「執行過程」,會造成「結果」不正確,測試也會失敗。
如果遇到這種情況,就得先靜下來傾聽自己內心的聲音,在該測試案例中究竟是「執行過程」重要或是「結果」重要?如果太貪心兩個都要,可能就會造成過度指定。
過度指定是在 單元測試的藝術 [第二版] 一書中看來的,意思是你應該想想在你要測試的單一行為中,什麼才是這個行為真正重視的,以上面的例子而言,驗證了「過程」及「結果」就觸碰到了過度指定的線了,但是只用文字說明還是太抽象了對吧!其實早在前幾張剛學測試時,我們就寫出過度指定的測試案例了,猜猜看是什麼?
答案是__tests__/car.test.js,讓現在的我們看看這個例子:
在上方的測試案例裡,雖然 Mock 了 uuid
要提供預設值,也 Spy 了 getCurrentCarSpy
驗證是否有執行,但是對於 check_add_prod 來說,到底什麼才是最重要的?
這麼說就很明顯了吧!當然是最後的結果是否有加進 carContent
中,而且因為我們替 Mock 的 uuid
設置了 mockReturnValue
,所以如果 uuid
沒執行期望中的 id
就不會是 '9999'
,測試就會失敗,getCurrentCar
也是相同的,若它沒有正常的取出 carContent
,又怎麼會得出最後的結果呢?
所以動動手,試著修改一下 check_add_prod 吧!修改後的結果會放到今天的 GitHub 中,可以再去翻閱確認修改方向正不正確。
聽起來只需要驗證最後的結果就行了,那什麼時候又該「驗證執行過程」呢?
其實在上一個篇章,就有個測試案例很好的去驗證了執行過程:
光是點擊按鈕這個動作,是不會有任何回傳值可以提供驗證的,這時候我們選擇對觸發 Reducer 的 dispatch
做 Mock,並在該測試案例中僅驗證執行時所帶入的 Action 是否正確,如果 dispatch
沒執行的話,測試案例便會出錯。
本文的範例程式碼會提供在 GitHub 上,歡迎各位參考:)
本篇文章在最後提出過度指定這個非常重要的概念,並來個回馬槍打翻之前學習 Mock 的例子(check_add_prod 的部分),希望大家會喜歡這個安排,也可以更了解如何避免過度指定,以及察覺自己寫的案例中潛藏的問題。
如果文章中有任何問題,或是不理解的地方,都可以留言告訴我!謝謝大家!
所以過度指定 希望的是在單元測試中主要是測試結果 除非不會有結果才會測試過程 嗎?
對!「在單元測試中主要是測試結果 除非不會有結果才會測試過程」這個是測試的方式,畢竟沒有結果就只能測該函式有沒有被呼叫了。
重點是要在該測試裡找到一項你要測試的目的,如果你測試過多(重複性的)就是過度指定了。